<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Java coach</title>
  <style>
    body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial;margin:0;background:#f6f7fb;color:#1f2937}
    .wrap{max-width:960px;margin:24px auto;padding:0 16px}
    .card{background:#fff;border:1px solid #e6e9f2;border-radius:12px;padding:12px;margin-top:12px}
    .toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
    input,select,button{height:36px;border:1px solid #e6e9f2;border-radius:8px;padding:0 10px;background:#fff}
    button.primary{background:#2563eb;color:#fff;border-color:#2563eb}
    .badge{display:inline-flex;gap:6px;align-items:center;border:1px solid #e6e9f2;border-radius:999px;padding:4px 10px;font-size:12px;background:#fff;color:#6b7280}
    .state-ok{background:#10b981;color:#fff;border-color:#10b981}
    .state-warn{background:#f59e0b;color:#fff;border-color:#f59e0b}
    .state-bad{background:#ef4444;color:#fff;border-color:#ef4444}
    .btn-on{background:#10b981 !important; color:#fff !important; border-color:#10b981 !important}
    .btn-off{background:#ef4444 !important; color:#fff !important; border-color:#ef4444 !important}
    .btn-disabled{opacity:.6; cursor:not-allowed}
    .log{white-space:pre-wrap;background:#fff;border:1px solid #e6e9f2;border-radius:12px;height:200px;overflow:auto;padding:10px}
    .tips{white-space:pre-wrap;line-height:1.4}
    .tip-divider{border-top:2px solid #94a3b8;margin:12px 0; opacity:.9}
  </style>
</head>
<body>
  <div class="wrap">
    <h2>Java coach</h2>
    <div class="card toolbar">
      <label>服务器</label>
      <input id="wsUrl" style="width:360px" value="ws://localhost:8080/ws/coach"/>
      <label>语言</label>
      <select id="lang"><option>ZH</option></select>
      <label>采样率</label>
      <select id="rate"><option>16000</option></select>
      <label>设备</label>
      <select id="deviceSelect"><option value="">默认设备</option></select>
      <button id="btnRefresh" onclick="refreshDevices()">刷新设备</button>
      <button id="btnConnect" class="primary" onclick="connectWs()">连接</button>
      <button id="btnDisconnect" onclick="disconnectWs()" class="btn-disabled" disabled>断开</button>
      <button id="btnStart" onclick="startStream()" class="btn-disabled" disabled>开始</button>
      <button id="btnStop" onclick="stopStream()" class="btn-disabled" disabled>停止</button>
      <button id="btnMic" onclick="toggleMic()">麦克风 开</button>
      <span id="wsBadge" class="badge">未连接</span>
      <span id="streamBadge" class="badge">未开始</span>
      <span id="asrCost" class="badge">ASR：—</span>
      <span id="llmCost" class="badge">LLM：—</span>
    </div>

    <div class="card">
      <h3>实时识别</h3>
      <div id="log" class="log"></div>
    </div>

    <div class="card">
      <h3>回答提示</h3>
      <div id="tips" class="tips">（每次识别到完整一句后，自动生成简短要点与话术）</div>
    </div>
  </div>

<script>
let ws,mediaStream,audioContext,processor;
let isConnected=false, isStreaming=false, micOn=false;
function logLine(t){const el=document.getElementById('log'); el.textContent+=t+'\n'; el.scrollTop=el.scrollHeight;}
function setAsr(ms){document.getElementById('asrCost').textContent='ASR：'+ms;}
function setLlm(ms){document.getElementById('llmCost').textContent='LLM：'+ms;}

function connectWs(){
  const url=document.getElementById('wsUrl').value;
  setBtnBusy('btnConnect', true);
  ws=new WebSocket(url); ws.binaryType='arraybuffer';
  ws.onopen=()=>{ isConnected=true; setBtnBusy('btnConnect', false); setConnUI(true); };
  ws.onclose=()=>{ isConnected=false; isStreaming=false; setConnUI(false); };
  ws.onerror=()=>{ setBtnBusy('btnConnect', false); };
  ws.onmessage=(ev)=>{
    try{const d=JSON.parse(ev.data);
      if(d.event==='partial'){logLine('[partial] '+d.text);} 
      else if(d.event==='final'){logLine('[final] '+d.text); if(d.asrCostMs!==undefined) setAsr(d.asrCostMs+' ms');}
      else if(d.event==='asr_progress'){setAsr('进行中…');}
      else if(d.event==='coach'){appendTips(d.tips||''); if(d.llmCostMs!==undefined) setLlm(d.llmCostMs+' ms');}
    }catch(e){logLine(ev.data)}
  };
}
function disconnectWs(){ if(ws){ ws.close(); ws=null; } }
function startStream(){ 
  if(!ws||ws.readyState!==1){ toast('未连接'); return; }
  setBtnBusy('btnStart', true);
  ws.send(JSON.stringify({type:'start', sampleRate: Number(document.getElementById('rate').value), lang: document.getElementById('lang').value}));
  isStreaming=true; setStreamUI(true); setBtnBusy('btnStart', false);
}
function stopStream(){ if(ws&&ws.readyState===1){ ws.send(JSON.stringify({type:'stop'})); } isStreaming=false; setStreamUI(false); }
async function toggleMic(){ 
  if(mediaStream){ stopMic(); micOn=false; setMicUI(false); return; }
  const deviceId=document.getElementById('deviceSelect').value; 
  const audioConstraints={channelCount:1,sampleRate:16000,echoCancellation:false,noiseSuppression:true,autoGainControl:false}; 
  if(deviceId){audioConstraints.deviceId={exact:deviceId};}
  try{ 
    mediaStream=await navigator.mediaDevices.getUserMedia({audio:audioConstraints}); 
    audioContext=new (window.AudioContext||window.webkitAudioContext)({sampleRate:16000}); 
    const source=audioContext.createMediaStreamSource(mediaStream); 
    processor=audioContext.createScriptProcessor(4096,1,1); 
    processor.onaudioprocess=(e)=>{ if(!ws||ws.readyState!==1) return; const input=e.inputBuffer.getChannelData(0); const pcm16=floatTo16BitPCM(input); ws.send(pcm16); };
    source.connect(processor); processor.connect(audioContext.destination);
    micOn=true; setMicUI(true);
  }catch(err){ toast('麦克风失败: '+err.message); }
}
function stopMic(){ if(processor){processor.disconnect(); processor=null;} if(audioContext){audioContext.close(); audioContext=null;} if(mediaStream){mediaStream.getTracks().forEach(t=>t.stop()); mediaStream=null;} }
function floatTo16BitPCM(a){const b=new ArrayBuffer(a.length*2);const v=new DataView(b);let o=0;for(let i=0;i<a.length;i++,o+=2){let s=Math.max(-1,Math.min(1,a[i]));v.setInt16(o,s<0?s*0x8000:s*0x7FFF,true);}return b;}
async function refreshDevices(){ try{await navigator.mediaDevices.getUserMedia({audio:true}); const devices=await navigator.mediaDevices.enumerateDevices(); const inputs=devices.filter(d=>d.kind==='audioinput'); const sel=document.getElementById('deviceSelect'); sel.innerHTML='<option value="">默认设备</option>'; inputs.forEach(d=>{const o=document.createElement('option'); o.value=d.deviceId; o.textContent=d.label||('麦克风 '+d.deviceId.substring(0,8)); sel.appendChild(o);}); }catch(e){console.log(e);} }
window.onload=function(){ refreshDevices(); }

function appendTips(text){
  const el=document.getElementById('tips');
  if(!text) return;
  // 移除占位提示
  if(el.textContent && el.textContent.indexOf('自动生成')!==-1 && el.children.length===0){ el.textContent=''; }
  const item=document.createElement('div');
  const ts=new Date().toLocaleTimeString();
  item.style.margin='6px 0';
  item.innerText=`[${ts}] ${text}`;
  // 新内容放到最上面
  if (el.firstChild) {
    el.insertBefore(item, el.firstChild);
    const div=document.createElement('div');
    div.className='tip-divider';
    el.insertBefore(div, item.nextSibling);
  } else {
    el.appendChild(item);
  }
}

function setConnUI(ok){
  const bConnect=document.getElementById('btnConnect');
  const bDisconnect=document.getElementById('btnDisconnect');
  const bStart=document.getElementById('btnStart');
  const bStop=document.getElementById('btnStop');
  const wsBadge=document.getElementById('wsBadge');
  bConnect.disabled=ok; bConnect.classList.toggle('btn-disabled', ok);
  bDisconnect.disabled=!ok; bDisconnect.classList.toggle('btn-disabled', !ok);
  bStart.disabled=!ok; bStart.classList.toggle('btn-disabled', !ok);
  bStop.disabled=true; bStop.classList.add('btn-disabled');
  wsBadge.className='badge ' + (ok?'state-ok':'state-bad');
  wsBadge.textContent = ok ? '已连接' : '未连接';
}
function setStreamUI(ok){
  const bStart=document.getElementById('btnStart');
  const bStop=document.getElementById('btnStop');
  const badge=document.getElementById('streamBadge');
  bStart.disabled=ok; bStart.classList.toggle('btn-disabled', ok);
  bStop.disabled=!ok; bStop.classList.toggle('btn-disabled', !ok);
  badge.className='badge ' + (ok?'state-warn':'');
  badge.textContent = ok ? '已开始' : '未开始';
}
function setMicUI(on){
  const b=document.getElementById('btnMic');
  b.textContent = on ? '麦克风 关' : '麦克风 开';
  b.classList.toggle('btn-on', on);
  b.classList.toggle('btn-off', !on && mediaStream);
}
function setBtnBusy(id, busy){
  const b=document.getElementById(id);
  if(!b) return;
  if(busy){ b.dataset.oldText=b.textContent; b.textContent='…'; b.classList.add('btn-disabled'); b.disabled=true; }
  else { if(b.dataset.oldText){ b.textContent=b.dataset.oldText; delete b.dataset.oldText; } b.classList.remove('btn-disabled'); b.disabled=false; }
}
function toast(msg){ logLine('[提示] '+msg); }
</script>
</body>
</html>


